package cn.ryanpenn.javafx.samples.mist.mvp.views;

import cn.ryanpenn.javafx.samples.mist.mvp.injection.Injector;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.WeakChangeListener;
import javafx.collections.ObservableList;
import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Window;

import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.URL;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.logging.Logger;

import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static java.util.ResourceBundle.getBundle;

/**
 * Created on 2015/6/9.
 *
 * @author Misty
 */
public abstract class FXMLView<T> {
    protected final static Logger LOGGER = Logger.getLogger(FXMLView.class.getSimpleName());

    public final static String DEFAULT_VIEW_PREFIX = "view";
    public final static String DEFAULT_SKIN_PREFIX = "skin";

    protected String viewPrefix = DEFAULT_VIEW_PREFIX;
    protected String skinPrefix = DEFAULT_SKIN_PREFIX;

    protected ObjectProperty<T> presenterProperty;
    protected FXMLLoader fxmlLoader;
    protected String bundleName;
    protected ResourceBundle bundle;
    protected final Function<String, Object> injectionContext;
    protected URL resource;
    protected final static Executor PARENT_CREATION_POOL = Executors.newSingleThreadExecutor(runnable -> {
        Thread t = Executors.defaultThreadFactory().newThread(runnable);
        t.setDaemon(true);
        t.setName("mistFX-mvp FXMLView  - CREATION Thread");
        t.setUncaughtExceptionHandler(FXMLView::error);
        return t;
    });

    /**
     * Constructs the view lazily (fxml is not loaded) with empty injection
     * context.
     */
    public FXMLView() {
        this(f -> null);
    }

    /**
     * @param injectionContext the function is used as a injection source.
     *                         Values matching for the keys are going to be used for injection into the
     *                         corresponding presenter.
     */
    public FXMLView(Function<String, Object> injectionContext) {
        this.injectionContext = injectionContext;
        this.init(getClass(), getFXMLName());
    }

    public String getViewPrefix() {
        return this.viewPrefix;
    }

    public void setViewPrefix(String viewPrefix) {
        this.viewPrefix = viewPrefix;
    }

    public String getSkinPrefix() {
        return this.skinPrefix;
    }

    public void setSkinPrefix(String skinPrefix) {
        this.skinPrefix = skinPrefix;
    }

    private void init(Class clazz, final String conventionalName) {
        this.presenterProperty = new SimpleObjectProperty<>();

        String fxmlPath = this.getFxmlPath();
        this.resource = clazz.getClassLoader().getResource(conventionalName);
        if (resource == null) {
            throw new RuntimeException(new IOException("Error loading FXML - can't load from given resourcepath: "
                    + fxmlPath));
        }

        this.bundleName = getBundleName();
        this.bundle = getResourceBundle(bundleName);
    }

    private FXMLLoader loadSynchronously(final URL resource, ResourceBundle bundle, final String conventionalName) throws IllegalStateException {
        final FXMLLoader loader = new FXMLLoader(resource, bundle);
        loader.setControllerFactory((Class<?> p) ->
                Injector.instantiatePresenter(p, (t -> {
                    Object value = this.injectionContext.apply(t);
                    if (value == null && "fxmlView".equals(t)) {
                        value = this;
                    }
                    return value;
                })));
        try {
            loader.load();
        } catch (IOException ex) {
            error(Thread.currentThread(), ex);
            throw new IllegalStateException("Cannot load " + conventionalName, ex);
        } catch (Throwable t) {
            error(Thread.currentThread(), t);
        }
        return loader;
    }

    private void initializeFXMLLoader() {
        if (isNull(this.fxmlLoader)) {
            this.fxmlLoader = this.loadSynchronously(resource, bundle, bundleName);
            this.presenterProperty.set(this.fxmlLoader.getController());
        }
    }

    /**
     * Initializes the view by loading the FXML (if not happened yet) and
     * returns the top Node (parent) specified in
     *
     * @return the view
     */
    public Parent getView() {
        return this.getViewImpl();
    }

    /**
     * Initializes the view synchronously and invokes and passes the created
     * parent Node to the consumer within the FX UI thread.
     *
     * @param consumer - an object interested in received the Parent as callback
     */
    public void getView(Consumer<Parent> consumer) {
        this.getViewImpl(consumer, Platform::runLater);
    }

    /**
     * Creates the view asynchronously using an internal thread pool and passes
     * the parent node withing the UI Thread.
     *
     * @param consumer - an object interested in received the Parent as callback
     */
    public void getViewAsync(Consumer<Parent> consumer) {
        this.getViewImpl(consumer, PARENT_CREATION_POOL);
    }

    /**
     * Scene Builder creates for each FXML document a root container. This
     * method omits the root container (e.g. AnchorPane) and gives you the
     * access to its first child.
     *
     * @return the first child of the AnchorPane
     */
    public Node getViewWithoutRootContainer() {
        final ObservableList<Node> children = getView().getChildrenUnmodifiable();
        if (children.isEmpty()) {
            return null;
        }
        return children.listIterator().next();
    }

    private void addCSSIfAvailable(Parent parent) {
        URL uri = getClass().getClassLoader().getResource(getStyleSheetName());
        if (uri == null) {
            return;
        }
        String uriToCss = uri.toExternalForm();
        parent.getStylesheets().add(uriToCss);
    }

    private void addVisibleListener(final Parent parent) {
        BooleanProperty show = new SimpleBooleanProperty(false);
        InvalidationListener lifecycleListener = observable -> {
            @SuppressWarnings("deprecation")
            boolean b = parent.impl_isTreeVisible() &&
                    parent.getScene() != null &&
                    parent.getScene().getWindow() != null &&
                    parent.getScene().getWindow().isShowing();
            if (b && !show.get()) {
                show.set(true);
                try {
                    onShow();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            if (!b && show.get()) {
                show.set(false);
                try {
                    onHide();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        };
        WeakInvalidationListener weakLifecycleListener = new WeakInvalidationListener(lifecycleListener);

        ChangeListener<Window> windowChangeListener = (observable, oldWindow, newWindow) -> {
            if (oldWindow == newWindow) {
                return;
            }
            if (oldWindow != null) {
                oldWindow.showingProperty().removeListener(weakLifecycleListener);
            }
            if (newWindow != null) {
                newWindow.showingProperty().addListener(weakLifecycleListener);
            }
        };
        WeakChangeListener<Window> weakWindowChangeListener = new WeakChangeListener<>(windowChangeListener);

        ChangeListener<Scene> sceneChangeListener = (observable, oldScene, newScene) -> {
            if (nonNull(oldScene)) {
                oldScene.windowProperty().removeListener(weakWindowChangeListener);
                oldScene.windowProperty().removeListener(weakLifecycleListener);
                windowChangeListener.changed(
                        oldScene.windowProperty(),
                        oldScene.getWindow(),
                        null);
            }
            if (nonNull(newScene)) {
                newScene.windowProperty().addListener(weakWindowChangeListener);
                newScene.windowProperty().addListener(weakLifecycleListener);
                windowChangeListener.changed(
                        newScene.windowProperty(),
                        null,
                        newScene.getWindow());
            }
        };

        parent.sceneProperty().addListener(sceneChangeListener);
        parent.sceneProperty().addListener(lifecycleListener);
        parent.visibleProperty().addListener(lifecycleListener);
    }

    final String getStyleSheetName() {
        return getConventionalName(this.skinPrefix, ".css");
    }

    /**
     * In case the view was not initialized yet, the conventional fxml
     * (airhacks.fxml for the AirhacksView and AirhacksPresenter) are loaded and
     * the specified presenter / controller is going to be constructed and
     * returned.
     *
     * @return the corresponding controller / presenter (usually for a
     * AirhacksView the AirhacksPresenter)
     */
    public T getPresenter() {
        this.getViewImpl();
        return this.presenterProperty.get();
    }

    /**
     * Does not initialize the view. Only registers the Consumer and waits until
     * the the view is going to be created / the method FXMLView#getView or
     * FXMLView#getViewAsync invoked.
     *
     * @param presenterConsumer listener for the presenter construction
     */
    public void getPresenter(Consumer<T> presenterConsumer) {
        presenterProperty.addListener((observable, oldValue, newValue) -> {
            presenterConsumer.accept(newValue);
        });
    }

    String getConventionalName(String prefix, String ending) {
        return prefix + "/" + getConventionalName() + ending;
    }

    /**
     * @return the name of the view without the "View" prefix in lowerCase. For
     * AirhacksView just airhacks is going to be returned.
     */
    String getConventionalName() {
        return this.getClass().getSimpleName().toLowerCase();
    }

    public static ResourceBundle getResourceBundle(String name) {
        try {
            return getBundle(name);
        } catch (MissingResourceException ex) {
            return null;
        }
    }

    /**
     * @return an existing resource bundle, or null
     */
    public ResourceBundle getResourceBundle() {
        return this.bundle;
    }

    public boolean isInitialized() {
        return nonNull(fxmlLoader);
    }

    final Parent getViewImpl() {
        Parent parent;
        try {
            if (isNull(fxmlLoader)) {
                synchronized (this) {
                    if (isNull(fxmlLoader)) {
                        this.initializeFXMLLoader();
                        Parent root = fxmlLoader.getRoot();
                        addCSSIfAvailable(root);
                        addVisibleListener(root);
                    }
                }
            }
            parent = fxmlLoader.getRoot();
        } catch (Exception e) {
            error(Thread.currentThread(), e);
            throw new RuntimeException(e);
        }
        return parent;
    }

    final void getViewImpl(Consumer<Parent> consumer, Executor loadExecutor) {
        Supplier<Parent> supplier = this::getViewImpl;
        Executor fxExecutor = Platform::runLater;
        CompletableFuture.supplyAsync(supplier, loadExecutor).thenAcceptAsync(consumer, fxExecutor);
    }

    /**
     * @return the name of the fxml file derived from the FXML view. e.g. The
     * name for the AirhacksView is going to be airhacks.fxml.
     */
    protected String getFXMLName() {
        return getConventionalName(this.viewPrefix, ".fxml");
    }

    final String getFxmlPath() {
        final StringBuilder path = new StringBuilder();
        path.append("/");
        Package p = this.getClass().getPackage();
        if (p != null) {
            path.append(p.getName().replaceAll("\\.", "/"));
            path.append("/");
        }
        path.append(this.getFXMLName());
        return path.toString();
    }

    String getBundleName() {
        String conventionalName = getConventionalName();
        Package p = this.getClass().getPackage();
        return p != null ? p.getName() + "." + conventionalName :
                conventionalName;
    }

    private static void error(Thread thread, Throwable throwable) {
        System.err.println("Exception caught in: " + thread);
        StringWriter sw = new StringWriter();
        try (PrintWriter pw = new PrintWriter(sw)) {
            throwable.printStackTrace(pw);
            LOGGER.warning(sw.toString());
        }
    }

    protected void onShow() {
    }

    protected void onHide() {

    }
}